1 package edu.jiangxin.apktoolbox.file.duplicate;
2
3 import edu.jiangxin.apktoolbox.utils.DateUtils;
4 import edu.jiangxin.apktoolbox.swing.extend.FileListPanel;
5 import edu.jiangxin.apktoolbox.swing.extend.EasyPanel;
6 import edu.jiangxin.apktoolbox.utils.Constants;
7 import edu.jiangxin.apktoolbox.utils.FileUtils;
8 import edu.jiangxin.apktoolbox.utils.RevealFileUtils;
9 import org.apache.commons.codec.digest.DigestUtils;
10 import org.apache.commons.io.FilenameUtils;
11 import org.apache.commons.lang3.StringUtils;
12
13 import javax.swing.*;
14 import javax.swing.table.DefaultTableModel;
15 import java.awt.event.ActionEvent;
16 import java.awt.event.ActionListener;
17 import java.awt.event.MouseAdapter;
18 import java.awt.event.MouseEvent;
19 import java.io.*;
20 import java.util.List;
21 import java.util.*;
22 import java.util.concurrent.ExecutorService;
23 import java.util.concurrent.Executors;
24 import java.util.concurrent.Future;
25 import java.util.concurrent.atomic.AtomicInteger;
26
27 public class DuplicateSearchPanel extends EasyPanel {
28
29 @Serial
30 private static final long serialVersionUID = 1L;
31
32 private JTabbedPane tabbedPane;
33
34 private JPanel optionPanel;
35
36 private FileListPanel fileListPanel;
37
38 private JCheckBox isFileNameChecked;
39 private JCheckBox isMD5Checked;
40 private JCheckBox isModifiedTimeChecked;
41
42 private JCheckBox isHiddenFileSearched;
43 private JCheckBox isRecursiveSearched;
44 private JTextField suffixTextField;
45
46 private JPanel resultPanel;
47
48 private JTable resultTable;
49
50 private DefaultTableModel resultTableModel;
51
52 private JButton searchButton;
53 private JButton cancelButton;
54
55 private JProgressBar progressBar;
56
57 private JMenuItem openDirMenuItem;
58 private JMenuItem deleteFileMenuItem;
59 private JMenuItem deleteFilesInSameDirMenuItem;
60 private JMenuItem deleteFilesInSameDirRecursiveMenuItem;
61
62 private transient SearchThread searchThread;
63
64 private transient final Map<String, List<File>> duplicateFileGroupMap = new HashMap<>();
65
66 @Override
67 public void initUI() {
68 tabbedPane = new JTabbedPane();
69 add(tabbedPane);
70
71 createOptionPanel();
72 tabbedPane.addTab("Option", null, optionPanel, "Show Search Options");
73
74 createResultPanel();
75 tabbedPane.addTab("Result", null, resultPanel, "Show Search Result");
76 }
77
78 private void createOptionPanel() {
79 optionPanel = new JPanel();
80 optionPanel.setLayout(new BoxLayout(optionPanel, BoxLayout.Y_AXIS));
81
82 fileListPanel = new FileListPanel();
83 fileListPanel.initialize();
84
85 JPanel checkOptionPanel = new JPanel();
86 checkOptionPanel.setLayout(new BoxLayout(checkOptionPanel, BoxLayout.X_AXIS));
87 checkOptionPanel.setBorder(BorderFactory.createTitledBorder("Check Options"));
88
89 JCheckBox isSizeChecked = new JCheckBox("Size");
90 isSizeChecked.setSelected(true);
91 isSizeChecked.setEnabled(false);
92 isFileNameChecked = new JCheckBox("Filename");
93 isMD5Checked = new JCheckBox("MD5");
94 isModifiedTimeChecked = new JCheckBox("Last Modified Time");
95 checkOptionPanel.add(isSizeChecked);
96 checkOptionPanel.add(Box.createHorizontalStrut(Constants.DEFAULT_X_BORDER));
97 checkOptionPanel.add(isFileNameChecked);
98 checkOptionPanel.add(Box.createHorizontalStrut(Constants.DEFAULT_X_BORDER));
99 checkOptionPanel.add(isMD5Checked);
100 checkOptionPanel.add(Box.createHorizontalStrut(Constants.DEFAULT_X_BORDER));
101 checkOptionPanel.add(isModifiedTimeChecked);
102 checkOptionPanel.add(Box.createHorizontalGlue());
103
104 JPanel searchOptionPanel = new JPanel();
105 searchOptionPanel.setLayout(new BoxLayout(searchOptionPanel, BoxLayout.X_AXIS));
106 searchOptionPanel.setBorder(BorderFactory.createTitledBorder("Search Options"));
107
108 isHiddenFileSearched = new JCheckBox("Hidden Files");
109 isRecursiveSearched = new JCheckBox("Recursive");
110 isRecursiveSearched.setSelected(true);
111 JLabel suffixLabel = new JLabel("Suffix: ");
112 suffixTextField = new JTextField();
113 suffixTextField.setToolTipText("an array of extensions, ex. {\"java\",\"xml\"}. If this parameter is empty, all files are returned.");
114 searchOptionPanel.add(isHiddenFileSearched);
115 searchOptionPanel.add(Box.createHorizontalStrut(Constants.DEFAULT_X_BORDER));
116 searchOptionPanel.add(isRecursiveSearched);
117 searchOptionPanel.add(Box.createHorizontalStrut(Constants.DEFAULT_X_BORDER));
118 searchOptionPanel.add(suffixLabel);
119 searchOptionPanel.add(suffixTextField);
120 searchOptionPanel.add(Box.createHorizontalGlue());
121
122 JPanel operationPanel = new JPanel();
123 operationPanel.setLayout(new BoxLayout(operationPanel, BoxLayout.X_AXIS));
124 operationPanel.setBorder(BorderFactory.createTitledBorder("Operations"));
125
126 JPanel buttonPanel = new JPanel();
127 buttonPanel.setLayout(new BoxLayout(buttonPanel, BoxLayout.X_AXIS));
128
129 searchButton = new JButton("Search");
130 cancelButton = new JButton("Cancel");
131 cancelButton.setEnabled(false);
132 searchButton.addActionListener(new OperationButtonActionListener());
133 cancelButton.addActionListener(new OperationButtonActionListener());
134 operationPanel.add(searchButton);
135 operationPanel.add(Box.createHorizontalStrut(Constants.DEFAULT_X_BORDER));
136 operationPanel.add(Box.createHorizontalStrut(Constants.DEFAULT_X_BORDER));
137 operationPanel.add(cancelButton);
138 operationPanel.add(Box.createHorizontalGlue());
139
140 progressBar = new JProgressBar();
141 progressBar.setStringPainted(true);
142 progressBar.setString("Ready");
143
144 optionPanel.add(fileListPanel);
145 optionPanel.add(Box.createVerticalStrut(Constants.DEFAULT_Y_BORDER));
146 optionPanel.add(checkOptionPanel);
147 optionPanel.add(Box.createVerticalStrut(Constants.DEFAULT_Y_BORDER));
148 optionPanel.add(searchOptionPanel);
149 optionPanel.add(Box.createVerticalStrut(Constants.DEFAULT_Y_BORDER));
150 optionPanel.add(operationPanel);
151 optionPanel.add(Box.createVerticalStrut(Constants.DEFAULT_Y_BORDER));
152 optionPanel.add(progressBar);
153 }
154
155 private void createResultPanel() {
156 resultPanel = new JPanel();
157 resultPanel.setLayout(new BoxLayout(resultPanel, BoxLayout.Y_AXIS));
158
159 resultTableModel = new DuplicateFilesTableModel(new Vector<>(), DuplicateFilesConstants.COLUMN_NAMES);
160 resultTable = new JTable(resultTableModel);
161
162 resultTable.setDefaultRenderer(Vector.class, new DuplicateFilesTableCellRenderer());
163
164 for (int i = 0; i < resultTable.getColumnCount(); i++) {
165 resultTable.getColumn(resultTable.getColumnName(i)).setCellRenderer(new DuplicateFilesTableCellRenderer());
166 }
167
168 resultTable.addMouseListener(new MyMouseListener());
169
170 resultTable.setSelectionMode(ListSelectionModel.SINGLE_INTERVAL_SELECTION);
171
172 JScrollPane scrollPane = new JScrollPane(resultTable);
173 resultPanel.add(scrollPane);
174 }
175
176 private String getComparedKey(File file) {
177 StringBuilder sb = new StringBuilder();
178 sb.append("[Size][");
179 sb.append(DigestUtils.md5Hex(String.valueOf(file.length())));
180 sb.append("]");
181
182 if (isFileNameChecked.isSelected()) {
183 sb.append("[Filename][");
184 sb.append(DigestUtils.md5Hex(file.getName()));
185 sb.append("]");
186 }
187 if (isMD5Checked.isSelected()) {
188 sb.append("[MD5][");
189 try (InputStream is = new FileInputStream(file)) {
190 sb.append(DigestUtils.md5Hex(is));
191 } catch (FileNotFoundException e) {
192 logger.error("getComparedKey FileNotFoundException");
193 } catch (IOException e) {
194 logger.error("getComparedKey IOException");
195 }
196 sb.append("]");
197 }
198 if (isModifiedTimeChecked.isSelected()) {
199 sb.append("[ModifiedTime][");
200 sb.append(DigestUtils.md5Hex(String.valueOf(file.lastModified())));
201 sb.append("]");
202 }
203 logger.info("path: " + file.getAbsolutePath() + ", key: " + sb);
204 return sb.toString();
205 }
206
207 class MyMouseListener extends MouseAdapter {
208 @Override
209 public void mouseReleased(MouseEvent e) {
210 super.mouseReleased(e);
211 int r = resultTable.rowAtPoint(e.getPoint());
212 if (r >= 0 && r < resultTable.getRowCount()) {
213 resultTable.setRowSelectionInterval(r, r);
214 } else {
215 resultTable.clearSelection();
216 }
217 int rowIndex = resultTable.getSelectedRow();
218 if (rowIndex < 0) {
219 return;
220 }
221 if (e.isPopupTrigger() && e.getComponent() instanceof JTable) {
222 JPopupMenu popupmenu = new JPopupMenu();
223 MyMenuActionListener menuActionListener = new MyMenuActionListener();
224
225 openDirMenuItem = new JMenuItem("Open parent folder of this file");
226 openDirMenuItem.addActionListener(menuActionListener);
227 popupmenu.add(openDirMenuItem);
228
229 deleteFileMenuItem = new JMenuItem("Delete this duplicate file");
230 deleteFileMenuItem.addActionListener(menuActionListener);
231 popupmenu.add(deleteFileMenuItem);
232
233 deleteFilesInSameDirMenuItem = new JMenuItem("Delete these duplicate files in the same directory");
234 deleteFilesInSameDirMenuItem.addActionListener(menuActionListener);
235 popupmenu.add(deleteFilesInSameDirMenuItem);
236
237 deleteFilesInSameDirRecursiveMenuItem = new JMenuItem("Delete these duplicate files in the same directory(Recursive)");
238 deleteFilesInSameDirRecursiveMenuItem.addActionListener(menuActionListener);
239 popupmenu.add(deleteFilesInSameDirRecursiveMenuItem);
240
241 popupmenu.show(e.getComponent(), e.getX(), e.getY());
242 }
243 }
244 }
245
246 class MyMenuActionListener implements ActionListener {
247 @Override
248 public void actionPerformed(ActionEvent actionEvent) {
249 Object source = actionEvent.getSource();
250 if (source.equals(openDirMenuItem)) {
251 onOpenDir();
252 } else if (source.equals(deleteFileMenuItem)) {
253 onDeleteFile();
254 } else if (source.equals(deleteFilesInSameDirMenuItem)) {
255 onDeleteFilesInSameDir();
256 } else if (source.equals(deleteFilesInSameDirRecursiveMenuItem)) {
257 onDeleteFilesInSameDirRecursive();
258 } else {
259 logger.error("invalid source");
260 }
261 }
262
263 private void onOpenDir() {
264 int rowIndex = resultTable.getSelectedRow();
265 String parentPath = resultTableModel.getValueAt(rowIndex, resultTable.getColumn(DuplicateFilesConstants.COLUMN_NAME_FILE_PARENT).getModelIndex()).toString();
266 File parent = new File(parentPath);
267 RevealFileUtils.revealDirectory(parent);
268 }
269
270 private void onDeleteFile() {
271 int rowIndex = resultTable.getSelectedRow();
272 String parentPath = resultTableModel.getValueAt(rowIndex, resultTable.getColumn(DuplicateFilesConstants.COLUMN_NAME_FILE_PARENT).getModelIndex()).toString();
273 String name = resultTableModel.getValueAt(rowIndex, resultTable.getColumn(DuplicateFilesConstants.COLUMN_NAME_FILE_NAME).getModelIndex()).toString();
274 File selectedFile = new File(parentPath, name);
275 String key = getComparedKey(selectedFile);
276 List<File> files = duplicateFileGroupMap.get(key);
277 for (File file : files) {
278 if (selectedFile.equals(file)) {
279 files.remove(file);
280 boolean isSuccessful = file.delete();
281 logger.info("delete file: " + file.getAbsolutePath() + ", result: " + isSuccessful);
282 break;
283 }
284 }
285 resultTableModel.setRowCount(0);
286 showResult();
287 }
288
289 private void onDeleteFilesInSameDir() {
290 int rowIndex = resultTable.getSelectedRow();
291 String parentPath = resultTableModel.getValueAt(rowIndex, resultTable.getColumn(DuplicateFilesConstants.COLUMN_NAME_FILE_PARENT).getModelIndex()).toString();
292 for (Map.Entry<String, List<File>> entry : duplicateFileGroupMap.entrySet()) {
293 List<File> duplicateFileGroup = entry.getValue();
294 for (File duplicateFile : duplicateFileGroup) {
295 String parentPathTmp = duplicateFile.getParent();
296 if (Objects.equals(parentPath, parentPathTmp)) {
297 duplicateFileGroup.remove(duplicateFile);
298 boolean isSuccessful = duplicateFile.delete();
299 logger.info("delete file: " + duplicateFile.getAbsolutePath() + ", result: " + isSuccessful);
300 break;
301 }
302 }
303 }
304 resultTableModel.setRowCount(0);
305 showResult();
306 }
307
308 private void onDeleteFilesInSameDirRecursive() {
309 int rowIndex = resultTable.getSelectedRow();
310 String parentPath = resultTableModel.getValueAt(rowIndex, resultTable.getColumn(DuplicateFilesConstants.COLUMN_NAME_FILE_PARENT).getModelIndex()).toString();
311 for (Map.Entry<String, List<File>> entry : duplicateFileGroupMap.entrySet()) {
312 List<File> duplicateFileGroup = entry.getValue();
313 for (File duplicateFile : duplicateFileGroup) {
314 String parentPathTmp = duplicateFile.getParent();
315 if (Objects.equals(parentPath, parentPathTmp) || FilenameUtils.directoryContains(parentPath, parentPathTmp)) {
316 duplicateFileGroup.remove(duplicateFile);
317 boolean isSuccessful = duplicateFile.delete();
318 logger.info("delete file: " + duplicateFile.getAbsolutePath() + ", result: " + isSuccessful);
319 break;
320 }
321 }
322 }
323 resultTableModel.setRowCount(0);
324 showResult();
325 }
326 }
327
328 class OperationButtonActionListener implements ActionListener {
329 @Override
330 public void actionPerformed(ActionEvent e) {
331 Object source = e.getSource();
332 if (source.equals(searchButton)) {
333 searchButton.setEnabled(false);
334 cancelButton.setEnabled(true);
335 String[] extensions = null;
336 if (StringUtils.isNotEmpty(suffixTextField.getText())) {
337 extensions = suffixTextField.getText().split(",");
338 }
339 searchThread = new SearchThread(extensions, isRecursiveSearched.isSelected(), isHiddenFileSearched.isSelected(), duplicateFileGroupMap);
340 searchThread.start();
341 } else if (source.equals(cancelButton)) {
342 searchButton.setEnabled(true);
343 cancelButton.setEnabled(false);
344 if (searchThread.isAlive()) {
345 searchThread.interrupt();
346 searchThread.executorService.shutdownNow();
347 }
348 }
349
350 }
351 }
352
353 private void showResult() {
354 SwingUtilities.invokeLater(() -> {
355 int groupIndex = 0;
356 for (Map.Entry<String, List<File>> entry : duplicateFileGroupMap.entrySet()) {
357 List<File> duplicateFileGroup = entry.getValue();
358 if (duplicateFileGroup.size() < 2) {
359 continue;
360 }
361 groupIndex++;
362 for (File duplicateFile : duplicateFileGroup) {
363 Vector<Object> rowData = getRowVector(groupIndex, duplicateFile);
364 resultTableModel.addRow(rowData);
365 }
366 }
367 tabbedPane.setSelectedIndex(1);
368 });
369 }
370
371 private Vector<Object> getRowVector(int groupIndex, File file) {
372 Vector<Object> rowData = new Vector<>();
373 rowData.add(groupIndex);
374 rowData.add(file.getParent());
375 rowData.add(file.getName());
376 rowData.add(FilenameUtils.getExtension(file.getName()));
377 rowData.add(FileUtils.sizeOfInHumanFormat(file));
378 rowData.add(DateUtils.millisecondToHumanFormat(file.lastModified()));
379 return rowData;
380 }
381
382 class SearchThread extends Thread {
383 private final ExecutorService executorService;
384 private final AtomicInteger processedFiles = new AtomicInteger(0);
385 private int totalFiles = 0;
386 private final String[] extensions;
387 private final boolean isRecursiveSearched;
388 private final boolean isHiddenFileSearched;
389 private final Map<String, List<File>> duplicateFileGroupMap;
390
391 public SearchThread(String[] extensions, boolean isRecursiveSearched, boolean isHiddenFileSearched, Map<String, List<File>> duplicateFileGroupMap) {
392 super();
393 this.extensions = extensions;
394 this.isRecursiveSearched = isRecursiveSearched;
395 this.isHiddenFileSearched = isHiddenFileSearched;
396 this.duplicateFileGroupMap = duplicateFileGroupMap;
397 this.executorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
398
399 SwingUtilities.invokeLater(() -> {
400 progressBar.setValue(0);
401 progressBar.setString("Starting search...");
402 });
403 }
404
405 @Override
406 public void run() {
407 try {
408 duplicateFileGroupMap.clear();
409 SwingUtilities.invokeLater(() -> resultTableModel.setRowCount(0));
410
411 List<File> fileList = fileListPanel.getFileList();
412 Set<File> fileSet = new TreeSet<>(fileList);
413 for (File file : fileList) {
414 fileSet.addAll(org.apache.commons.io.FileUtils.listFiles(file, extensions, isRecursiveSearched));
415 }
416
417
418 Map<Long, List<File>> sizeGroups = new HashMap<>();
419 for (File file : fileSet) {
420 if (currentThread().isInterrupted()) {
421 return;
422 }
423 if (file.isHidden() && !isHiddenFileSearched) {
424 continue;
425 }
426 sizeGroups.computeIfAbsent(file.length(), k -> new ArrayList<>()).add(file);
427 }
428
429
430 List<Future<?>> futures = new ArrayList<>();
431 totalFiles = fileSet.size();
432 updateProgress();
433
434 for (Map.Entry<Long, List<File>> entry : sizeGroups.entrySet()) {
435 if (entry.getValue().size() > 1) {
436 futures.add(executorService.submit(() -> {
437 processFileGroup(entry.getValue());
438 return null;
439 }));
440 } else {
441
442 incrementProcessedFiles();
443 }
444 }
445
446
447 for (Future<?> future : futures) {
448 try {
449 future.get();
450 } catch (InterruptedException e) {
451 logger.error("Search interrupted", e);
452 currentThread().interrupt();
453 return;
454 }
455 }
456
457 showResult();
458 } catch (Exception e) {
459 logger.error("Search failed", e);
460 SwingUtilities.invokeLater(() -> progressBar.setString("Search failed"));
461 } finally {
462 executorService.shutdown();
463 SwingUtilities.invokeLater(() -> {
464 searchButton.setEnabled(true);
465 cancelButton.setEnabled(false);
466 });
467 }
468 }
469
470 private void processFileGroup(List<File> files) {
471 Map<String, List<File>> groupMap = new HashMap<>();
472 for (File file : files) {
473 if (currentThread().isInterrupted()) {
474 return;
475 }
476 String key = getComparedKey(file);
477 groupMap.computeIfAbsent(key, k -> new ArrayList<>()).add(file);
478 incrementProcessedFiles();
479 }
480
481
482 synchronized (duplicateFileGroupMap) {
483 for (Map.Entry<String, List<File>> entry : groupMap.entrySet()) {
484 if (entry.getValue().size() > 1) {
485 duplicateFileGroupMap.put(entry.getKey(), entry.getValue());
486 }
487 }
488 }
489 }
490
491 private void incrementProcessedFiles() {
492 processedFiles.incrementAndGet();
493 updateProgress();
494 }
495
496 private void updateProgress() {
497 if (totalFiles > 0) {
498 SwingUtilities.invokeLater(() -> {
499 int processed = processedFiles.get();
500 int percentage = (int) (processed * 100.0 / totalFiles);
501 progressBar.setValue(percentage);
502 progressBar.setString(String.format("Processing: %d/%d files (%d%%)",
503 processed, totalFiles, percentage));
504 });
505 }
506 }
507 }
508 }